fix(ts): back off on nack/ack failure in consumer#289
Conversation
samorev Code Review Report
BLOCKING ISSUES (1)HIGH
Summary
Note:
Review metadatasamorev-assisted review (AI analysis by Tanya301/samorev) |
The Consumer.start() loop slept pollInterval only on receive error or empty batch. When a batch was received but finalization failed (a nack failed so ack was skipped, or ack threw), the loop re-polled instantly; since the batch was never finished, pgque.next_batch returned the same batch immediately. A persistent nack/ack failure (e.g. partial grants where receive works but nack/ack do not) therefore hot-looped at full speed, re-running every handler with duplicate side effects, and even a single transient ack failure re-executed the batch with zero delay. Sleep pollIntervalMs (abort-aware) before re-polling on both paths. Ack returning 0 without throwing stays warning-only with no sleep. Red/green: the two new mock-based tests hot-loop so hard unfixed that the vitest worker dies of OOM recording mock calls; green after fix. Addresses finding B1 (TypeScript) of #283. https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv
producer_bench.ts spliced the table name returned by pgque.current_event_table() into 'select count(*) from ...' via a raw template literal -- the only non-parameterized query in the repo. The value is self-generated and the script is dev-only, so this is not exploitable, but quote it properly anyway with pg's escapeIdentifier, part by part for the schema-qualified name. Also fix the result generic: the pgque pool parses int8 (OID 20) to BigInt, so count(*) arrives as bigint, not string; the old code only worked because Number.parseInt coerces its argument. Verified: bun run check, plus a live run against local PG 16 (PGQUE_BENCH_REPEATS=1 bun src/producer_bench.ts) where verifyCount passes for all batch sizes. Addresses the producer_bench informational note of #283. https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv
88600bb to
1f59900
Compare
Deep review + real testing (0.3.0-alpha1 readiness)Reviewed as unreviewed (the earlier "samorev" pass was disregarded). Rebased onto current RebaseBranch was based on pre-split CIAll 16 checks green on Review findingsBackoff correctness — correct.
Double-processing semantics — inherent, correctly rate-limited. The skip-ack strategy means an unfinished batch is redelivered whole, so already-succeeded handlers in that batch re-run on redelivery (at-least-once). This PR does not change that contract; it only stops the redelivery from happening in a zero-delay tight loop. The updated comments describe this accurately (the old comment's "redelivered on the next poll" claim referenced a poll interval that did not exist on that path — now it does). Bench-script interpolation — fixed correctly. TDD test — genuine red/green. The two new mock tests ( Real testing (local, PG 16, devel build)Fresh Red/green confirmation of the backoff fix (mock suite): Bench-script fix exercised live (hits the VerdictMERGE-READY. No blockers. |
samorev Code Review Report
No blocking issues. Reviewed for security, bugs, tests, guidelines, and documentation. One candidate was examined and filtered as an accepted design point (see below). Result: PASSED What was verified
Filtered (candidate examined, excluded as a non-issue)
Summary
Note:
Review metadatasamorev-assisted review (AI analysis by Tanya301/samorev) |
Bug
Consumer.start()inclients/typescript/src/consumer.tssleptpollIntervalMsonly on receive error or empty batch. When a batch was received but finalization failed — a nack failed so ack was intentionally skipped, orack()threw — the while loop re-polled immediately. Because the batch was never finished,pgque.next_batchreturns the same batch instantly, so:receiveworks butnack/ackdon't) becomes a tight loop at full speed: re-receive → re-run ALL handlers (duplicate side effects) → fail → repeat, hammering both the application and Postgres;The comment on the skip-ack path even claimed redelivery happens "on the next poll" — a poll interval that did not exist on that path.
Fix
await sleep(this.pollIntervalMs, signal)before re-polling on both theanyNackFailedpath and the ack-throw path. The existingsleephelper is abort-aware, so shutdown latency is unchanged.ackreturning 0 without throwing stays warning-only with no sleep (the batch is finished in that case; the loop should keep draining).Red/green TDD: two new mock-based tests (
pollInterval: 60_000,receivealways returns the same batch, nack/ack fail persistently) assertreceiveis called exactly once within a 300 ms observation window. Unfixed, the hot loop is so tight that the vitest worker dies of OOM recording mock calls — that was the red run. Green after the fix.Second commit (informational note from the same review):
src/producer_bench.tsspliced the table name frompgque.current_event_table()intoselect count(*) from ${table}via a template literal — not exploitable (self-generated value, dev-only script) but the only non-parameterized query in the repo. Now quoted part by part with pg'sescapeIdentifier. Also fixed the result generic: the pgque pool parses int8 (OID 20) toBigInt, socount(*)arrives asbigint, notstring; the old<{ count: string }>+Number.parseIntonly worked by coercion.Verification
All run in
clients/typescript/(deps viabun install):Red run (before the fix, same test file): the
consumer.test.tsworker crashed withFATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory— direct evidence of the unbounded hot loop.Addresses finding B1 (TypeScript) and the producer_bench informational note of #283
https://claude.ai/code/session_01KAaEGkQZmey1D1xCsVGmqv
Generated by Claude Code